agentmux_srv\backend\storage/agents.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent subsystem — definitions, instances, and their lifecycle CRUD.
5//!
6//! Covers `agent_def_*` methods (template + user-clone definitions),
7//! `instance_*` methods (per-launch instance rows, named-agent
8//! continuation, identity-bound active-for-block resolution), the
9//! `AgentDefinition` / `AgentInstance` structs, and the `InstanceStatus` enum.
10//! `db_agents` is the authoritative consolidated read table; `db_agent_definitions`
11//! and `db_agent_instances` remain the write targets with dual-write mirrors.
12
13use rusqlite::params;
14use serde::{Deserialize, Serialize};
15
16use super::error::StoreError;
17use super::store::Store;
18
19/// A user-defined AI agent in the user's agent-definition catalog.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct AgentDefinition {
22 pub id: String,
23 /// Stable, filesystem-safe identifier. Drives working directory,
24 /// env var keys (AGENTMUX_AGENT_ID), and cross-references.
25 /// NEVER changes after creation — distinct from `name` which is
26 /// the renameable display. See
27 /// specs/SPEC_AGENT_IDENTITY_RESTRUCTURE_2026_04_14.md.
28 #[serde(default)]
29 pub slug: String,
30 pub name: String,
31 pub icon: String,
32 pub provider: String,
33 pub description: String,
34 #[serde(default)]
35 pub working_directory: String,
36 #[serde(default)]
37 pub shell: String,
38 #[serde(default)]
39 pub provider_flags: String,
40 #[serde(default)]
41 pub auto_start: i64,
42 #[serde(default)]
43 pub restart_on_crash: i64,
44 #[serde(default)]
45 pub idle_timeout_minutes: i64,
46 pub created_at: i64,
47 #[serde(default = "default_agent_type")]
48 pub agent_type: String,
49 #[serde(default)]
50 pub environment: String,
51 #[serde(default)]
52 pub agent_bus_id: String,
53 #[serde(default)]
54 pub is_seeded: i64,
55 /// JSON-encoded per-provider account assignments
56 /// (`{"github":"acct-id", …}`). Written by the Agent pane's Identity
57 /// tab (`AgentIdentityPanel`) via `updateagent`, read back by
58 /// `parseAgentAccounts` and consumed by startup credential
59 /// resolution. (An older v6 comment called this deprecated in favour
60 /// of `db_agent_identity_links`; that migration never completed — the
61 /// JSON blob is still the live store, so the schema flatten keeps the
62 /// column.)
63 #[serde(default)]
64 pub accounts: String,
65 /// Parent definition id (db_agent_definitions.id). Empty string = root
66 /// definition; non-empty = this agent was forked from another.
67 /// Added in v6. See spec §Phase 1.
68 #[serde(default)]
69 pub parent_id: String,
70 /// Label describing the branch (e.g. `"pr-422-review"`,
71 /// `"experiment-refactor"`). Empty for root definitions and for
72 /// branches that didn't set a label. Added in v6.
73 #[serde(default)]
74 pub branch_label: String,
75 /// Last-modified timestamp (epoch ms). Set to `created_at` on insert
76 /// and refreshed on every `agent_def_update`. Schema v2. `0` for
77 /// rows written before v2 (until next update).
78 #[serde(default)]
79 pub updated_at: i64,
80 /// Per-user hide flag for seeded templates. `1` = the user clicked
81 /// "Hide template" on the picker's `+ New from template` tier; the
82 /// row stays on disk (templates are manifest-managed; deletion would
83 /// fight re-seed) but the default `listagents` view filters it out.
84 /// Reset to `0` by the agent-seed re-sync flow for any NEW template
85 /// id newly added to the manifest, so a fresh template surfaces once
86 /// even if a same-named one was previously hidden. Schema v3 (Phase
87 /// 2 of `SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md` — Q2 Decision Y).
88 /// User-owned rows (`is_seeded = 0`) MUST stay at `0` here; their
89 /// removal path is `deleteagent`, not hide.
90 #[serde(default)]
91 pub user_hidden: i64,
92 /// Docker image to use when `agent_type == "container"`.
93 /// e.g. `"ghcr.io/agentmuxai/agent-claude:latest"`.
94 /// Empty string for host agents. Schema v6.
95 #[serde(default)]
96 pub container_image: String,
97 /// JSON array of volume mount specs for container agents.
98 /// Each element is a string in Docker bind-mount format:
99 /// `"source:target"` or `"source:target:options"`.
100 /// Empty JSON array (`"[]"`) for host agents. Schema v6.
101 #[serde(default = "default_container_volumes")]
102 pub container_volumes: String,
103 /// Stable Docker container name managed by the server.
104 /// Set to `"agentmux-<slug>"` on first container spawn;
105 /// empty for host agents. Schema v6.
106 #[serde(default)]
107 pub container_name: String,
108}
109
110/// Derive a filesystem-safe slug from a display name. Lowercase,
111/// ASCII alphanumeric + dash/underscore, consecutive dashes collapsed,
112/// trimmed to 64 chars. Returns `"agent"` if the input has no valid
113/// characters (defensive fallback).
114pub fn derive_slug(name: &str) -> String {
115 let filtered: String = name
116 .to_lowercase()
117 .chars()
118 .map(|c| {
119 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
120 c
121 } else {
122 '-'
123 }
124 })
125 .collect();
126 let collapsed: String = filtered
127 .split('-')
128 .filter(|s| !s.is_empty())
129 .collect::<Vec<_>>()
130 .join("-");
131 let trimmed: String = collapsed.chars().take(64).collect();
132 if trimmed.is_empty() {
133 "agent".to_string()
134 } else {
135 trimmed
136 }
137}
138
139fn default_agent_type() -> String {
140 "standalone".to_string()
141}
142
143fn default_container_volumes() -> String {
144 "[]".to_string()
145}
146
147/// Instance lifecycle status. Serialised lowercase to match the DB text.
148#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
149#[serde(rename_all = "lowercase")]
150pub enum InstanceStatus {
151 Running,
152 Paused,
153 Stopped,
154 Crashed,
155 Detached,
156}
157
158impl InstanceStatus {
159 pub fn as_str(&self) -> &'static str {
160 match self {
161 Self::Running => "running",
162 Self::Paused => "paused",
163 Self::Stopped => "stopped",
164 Self::Crashed => "crashed",
165 Self::Detached => "detached",
166 }
167 }
168 pub fn parse(s: &str) -> Option<Self> {
169 match s {
170 "running" => Some(Self::Running),
171 "paused" => Some(Self::Paused),
172 "stopped" => Some(Self::Stopped),
173 "crashed" => Some(Self::Crashed),
174 "detached" => Some(Self::Detached),
175 _ => None,
176 }
177 }
178}
179
180/// Optional context describing which GitHub-side unit of work a specific
181/// instance is operating on. Stored as JSON in
182/// `db_agent_instances.github_context` (empty string when unset).
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct GitHubContext {
185 pub repo: String, // "owner/repo"
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub pr_number: Option<u32>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub branch: Option<String>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub issue_number: Option<u32>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub workflow_run_id: Option<u64>,
194}
195
196/// Partial-update payload for [`Store::instance_update_partial`]. Each
197/// `Some` field is written; `None` leaves the column untouched. Mirrors
198/// the mutable subset of `CommandUpdateAgentInstanceData`
199/// (`block_id`/`session_id`/`status`/`github_context`/`ended_at`) — the
200/// only columns `instance_update` ever wrote. `Some("")` for a string
201/// field explicitly clears it.
202#[derive(Debug, Clone, Default)]
203pub struct InstanceUpdate {
204 pub block_id: Option<String>,
205 pub session_id: Option<String>,
206 pub status: Option<String>,
207 pub github_context: Option<String>,
208 pub ended_at: Option<i64>,
209}
210
211/// One row per running/historical execution of an agent definition.
212/// `block_id` / `session_id` / `github_context` are modelled as empty
213/// strings on the wire rather than `Option<String>` to match the
214/// existing schema conventions (`NOT NULL DEFAULT ''`). Callers
215/// that need structured absence can use `.is_empty()`.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct AgentInstance {
218 pub id: String,
219 pub definition_id: String,
220 #[serde(default)]
221 pub parent_instance_id: String,
222 #[serde(default)]
223 pub block_id: String,
224 #[serde(default)]
225 pub session_id: String,
226 pub status: String,
227 /// JSON-encoded `GitHubContext`, or empty string.
228 #[serde(default)]
229 pub github_context: String,
230 pub started_at: i64,
231 #[serde(default)]
232 pub ended_at: i64,
233 pub created_at: i64,
234 /// FK to `db_identity_bundles.id`. Empty string means "use the blank
235 /// singleton" (= ambient creds, no env-var injection). Set at
236 /// instantiation via the launch modal's Identity dropdown.
237 #[serde(default)]
238 pub identity_id: String,
239 /// FK to `db_memory_bundles.id`. Empty string means "use the blank
240 /// singleton" (= vanilla CLI, no instructions). Set at
241 /// instantiation via the launch modal's Memory dropdown.
242 #[serde(default)]
243 pub memory_id: String,
244 /// User-chosen instance name (becomes `AGENTMUX_AGENT_ID` in the
245 /// spawn env). Empty for pre-v8 rows and for un-named launches.
246 /// Drives the "Continue agent" dropdown in the launch modal.
247 #[serde(default)]
248 pub instance_name: String,
249 /// Absolute path returned by `allocate_agent_workdir` at spawn.
250 /// Stored explicitly (rather than re-derived from the slug at
251 /// continue-time) so the continue flow is robust against
252 /// slug-rule changes and user-side renames.
253 #[serde(default)]
254 pub working_directory: String,
255 /// Soft-delete flag for the "Forget agent" affordance. Hidden
256 /// rows stay on disk for audit + recovery.
257 #[serde(default)]
258 pub display_hidden: bool,
259}
260
261impl Store {
262 /// List all agent definitions, **most-recently-used first**.
263 ///
264 /// Reads from the consolidated `db_agents` table, ordered by
265 /// `updated_at DESC` then `created_at ASC`. Dual-write keeps
266 /// `db_agents.updated_at` fresh on every definition mutation AND every
267 /// instance lifecycle touch, so recency on a row tracks the last time
268 /// the agent was either edited or launched.
269 ///
270 /// Result-set shape: every `db_agents` row is returned — templates
271 /// (`is_template = 1`) and user-clone projections (`is_template = 0`)
272 /// each appear once. `parent_id` is sourced from
273 /// `db_agents.parent_template_id`.
274 ///
275 /// Find user-clone definitions for a given seeded template (rows
276 /// in `db_agent_definitions` with `is_seeded = 0` and
277 /// `parent_id = <template.id>`). Returns the most-recent-first.
278 ///
279 /// Reads `db_agent_definitions` directly — NOT the `db_agents`
280 /// consolidated view — because the latter surfaces template-
281 /// instance projection rows under the same
282 /// `is_template = 0 AND parent_template_id = <tpl>` shape as
283 /// user-clone defs, which would conflate two distinct things.
284 ///
285 /// Sole production caller today is the `template_promote`
286 /// migration's "did the user delete the deterministic-id
287 /// clone?" diagnostic logging and its tests. Kept public so
288 /// follow-up callers (e.g. a cleanup pass that GCs orphaned
289 /// pre-deterministic-id clones from earlier migration code)
290 /// can use it without re-deriving the schema.
291 pub fn user_clone_defs_for_template(
292 &self,
293 template_id: &str,
294 ) -> Result<Vec<AgentDefinition>, StoreError> {
295 let conn = self.conn.lock().unwrap();
296 let mut stmt = conn.prepare(
297 "SELECT id, slug, name, icon, provider, description,
298 working_directory, shell, provider_flags, auto_start,
299 restart_on_crash, idle_timeout_minutes, created_at,
300 agent_type, environment, agent_bus_id, is_seeded,
301 accounts, parent_id, branch_label, updated_at,
302 user_hidden, container_image, container_volumes, container_name
303 FROM db_agent_definitions
304 WHERE is_seeded = 0 AND parent_id = ?1
305 ORDER BY updated_at DESC, created_at DESC",
306 )?;
307 let rows = stmt.query_map(params![template_id], map_agent_definition_row)?;
308 let mut out = Vec::new();
309 for r in rows {
310 out.push(r?);
311 }
312 Ok(out)
313 }
314
315 /// Fetch a single agent definition by primary key. Reads
316 /// `db_agent_definitions` directly (not the `db_agents`
317 /// consolidated view that `agent_def_list` reads), so it
318 /// returns user-clone definitions and seeded templates, never
319 /// template-instance projection rows.
320 ///
321 /// Used by the `template_promote` migration's deterministic-id
322 /// idempotency check (see
323 /// `migrate_promote_template_sessions_v1`): every retry asks
324 /// "does the promote-target clone for this template already
325 /// exist?" and either reuses it or inserts it.
326 pub fn agent_def_get(&self, id: &str) -> Result<Option<AgentDefinition>, StoreError> {
327 // Try local SQLite first (conn released before global registry check).
328 let local = {
329 let conn = self.conn.lock().unwrap();
330 let mut stmt = conn.prepare(
331 "SELECT id, slug, name, icon, provider, description,
332 working_directory, shell, provider_flags, auto_start,
333 restart_on_crash, idle_timeout_minutes, created_at,
334 agent_type, environment, agent_bus_id, is_seeded,
335 accounts, parent_id, branch_label, updated_at,
336 user_hidden, container_image, container_volumes, container_name
337 FROM db_agent_definitions
338 WHERE id = ?1",
339 )?;
340 let mut rows = stmt.query_map(params![id], map_agent_definition_row)?;
341 match rows.next() {
342 Some(row) => Some(row?),
343 None => None,
344 }
345 }; // MutexGuard dropped here.
346 if local.is_some() {
347 return Ok(local);
348 }
349 // Not in local SQLite — check the global cross-channel registry.
350 // agent_def_list() already overlays this; keep agent_def_get consistent.
351 let Some(reg) = self.shared_def_registry() else {
352 return Ok(None);
353 };
354 match reg.get(id) {
355 Ok(Some(record)) => Ok(Some(super::def_registry_mirror::record_to_agent_definition(&record))),
356 Ok(None) => Ok(None),
357 Err(e) => {
358 tracing::warn!(agent_id = %id, error = %e, "agent_def_get: global registry lookup failed; returning not-found");
359 Ok(None)
360 }
361 }
362 }
363
364 pub fn agent_def_list(&self) -> Result<Vec<AgentDefinition>, StoreError> {
365 // Local SQLite: this channel's templates (seeded) + its own user agents.
366 let local: Vec<AgentDefinition> = {
367 let conn = self.conn.lock().unwrap();
368 let mut stmt = conn.prepare(
369 "SELECT id, slug, name, icon, provider, description,
370 working_directory, shell, provider_flags, auto_start,
371 restart_on_crash, idle_timeout_minutes, created_at,
372 agent_type, environment, agent_bus_id, is_seeded,
373 accounts, parent_template_id, branch_label, updated_at,
374 user_hidden, container_image, container_volumes, container_name
375 FROM db_agents
376 ORDER BY updated_at DESC, created_at ASC",
377 )?;
378 let rows = stmt.query_map([], map_agent_definition_row)?;
379 let mut agents = Vec::new();
380 for row in rows {
381 agents.push(row?);
382 }
383 agents
384 };
385 // conn dropped. Overlay the GLOBAL cross-channel user-agent roster so
386 // an agent created in another channel appears here too. The global
387 // store wins for user rows (it's authoritative and holds cross-channel
388 // agents); templates (seeded — never in the global store) come from
389 // local SQLite. Falls back to SQLite-only when the global store is
390 // absent or unreadable. (P0.2c.)
391 let Some(reg) = self.shared_def_registry() else {
392 return Ok(local);
393 };
394 let global = match reg.list_active() {
395 Ok(recs) => recs,
396 Err(e) => {
397 tracing::warn!(error = %e, "def registry: global list failed, using SQLite only");
398 return Ok(local);
399 }
400 };
401 let mut by_id: std::collections::HashMap<String, AgentDefinition> =
402 local.into_iter().map(|d| (d.id.clone(), d)).collect();
403 for rec in &global {
404 let def = super::def_registry_mirror::record_to_agent_definition(rec);
405 by_id.insert(def.id.clone(), def);
406 }
407 let mut result: Vec<AgentDefinition> = by_id.into_values().collect();
408 // Match the SQL ORDER BY: updated_at DESC, then created_at ASC.
409 result.sort_by(|a, b| {
410 b.updated_at
411 .cmp(&a.updated_at)
412 .then(a.created_at.cmp(&b.created_at))
413 });
414 Ok(result)
415 }
416
417 /// Count agent rows (used by seed engine to check if seeding is needed).
418 /// Reads from the consolidated `db_agents` table. The seed engine only
419 /// cares about `== 0` to decide "fresh database, seed templates".
420 pub fn agent_def_count(&self) -> Result<i64, StoreError> {
421 let conn = self.conn.lock().unwrap();
422 let count: i64 = conn.query_row(
423 "SELECT COUNT(*) FROM db_agents",
424 [],
425 |row| row.get(0),
426 )?;
427 Ok(count)
428 }
429
430 /// Whether a definition with `id` exists in the LOCAL channel's SQLite
431 /// (`db_agents`). Gates the cross-channel content/skills fallback: a
432 /// locally-known agent with genuinely empty content/skills must NOT
433 /// resurrect them from the global record. (reagent P1 on #1385.)
434 pub(super) fn agent_def_exists_local(&self, id: &str) -> Result<bool, StoreError> {
435 let conn = self.conn.lock().unwrap();
436 let exists: bool = conn.query_row(
437 "SELECT EXISTS(SELECT 1 FROM db_agents WHERE id = ?1)",
438 params![id],
439 |row| row.get(0),
440 )?;
441 Ok(exists)
442 }
443
444 /// Delete all seeded agents (is_seeded=1). Used by reseed to clear built-in agents.
445 pub fn agent_def_delete_seeded(&self) -> Result<usize, StoreError> {
446 // Reagent P1 round 4 on #1013: capture the cascaded instance ids
447 // BEFORE the FK cascade fires. The bulk delete on
448 // `db_agent_definitions` triggers `ON DELETE CASCADE` on
449 // `db_agent_instances` for every instance keyed off those
450 // templates; once the cascade runs, we can't query them anymore,
451 // and `db_agents` would be left holding orphaned instance
452 // projections. Capture → delete → drop projections.
453 let (rows, cascaded_inst_ids) = {
454 let conn = self.conn.lock().unwrap();
455 let mut stmt = conn.prepare(
456 "SELECT i.id FROM db_agent_instances i
457 INNER JOIN db_agent_definitions d ON i.definition_id = d.id
458 WHERE d.is_seeded = 1",
459 )?;
460 let ids: Vec<String> = stmt
461 .query_map([], |r| r.get::<_, String>(0))?
462 .collect::<Result<Vec<_>, _>>()?;
463 drop(stmt);
464 let rows = conn.execute("DELETE FROM db_agent_definitions WHERE is_seeded=1", [])?;
465 (rows, ids)
466 };
467 // Drop template projections AND any cascaded instance projections from
468 // db_agents. User-clone definition projections (`is_template = 0`, `id`
469 // is a def_id) are NOT touched here — they persist with the def row.
470 self.agents_dual_write_seeded_delete(&cascaded_inst_ids)?;
471 Ok(rows)
472 }
473
474 /// Insert a new agent definition. Auto-derives slug from name if empty,
475 /// resolves collisions by appending `-2`, `-3`, etc., and mutates
476 /// `agent.slug` so the caller sees the resolved value (important
477 /// for handlers that serialize the struct back to the frontend
478 /// after insert).
479 ///
480 /// The collision check + insert run under a single mutex lock,
481 /// so this is race-safe against concurrent inserts on the same
482 /// connection.
483 pub fn agent_def_insert(&self, agent: &mut AgentDefinition) -> Result<(), StoreError> {
484 let conn = self.conn.lock().unwrap();
485 let base = if agent.slug.is_empty() {
486 derive_slug(&agent.name)
487 } else {
488 agent.slug.clone()
489 };
490 // Collision-resolve: scan for existing slugs matching base or base-N.
491 // Reads uniqueness from `db_agents` — the consolidated table surfaces
492 // both definition slugs and template-instance projections, so a slug
493 // collision against an instance-derived row is caught here too.
494 let mut candidate = base.clone();
495 let mut n: u32 = 2;
496 loop {
497 let count: i64 = conn.query_row(
498 "SELECT COUNT(*) FROM db_agents WHERE slug = ?1",
499 params![candidate],
500 |row| row.get(0),
501 )?;
502 if count == 0 {
503 break;
504 }
505 candidate = format!("{}-{}", base, n);
506 n += 1;
507 }
508 agent.slug = candidate;
509 conn.execute(
510 "INSERT INTO db_agent_definitions (id, slug, name, icon, provider, description,
511 working_directory, shell, provider_flags, auto_start, restart_on_crash,
512 idle_timeout_minutes, created_at, agent_type, environment, agent_bus_id,
513 is_seeded, accounts, parent_id, branch_label, updated_at, user_hidden,
514 container_image, container_volumes, container_name)
515 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
516 ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)",
517 params![
518 agent.id,
519 agent.slug,
520 agent.name,
521 agent.icon,
522 agent.provider,
523 agent.description,
524 agent.working_directory,
525 agent.shell,
526 agent.provider_flags,
527 agent.auto_start,
528 agent.restart_on_crash,
529 agent.idle_timeout_minutes,
530 agent.created_at,
531 agent.agent_type,
532 agent.environment,
533 agent.agent_bus_id,
534 agent.is_seeded,
535 agent.accounts,
536 agent.parent_id,
537 agent.branch_label,
538 // New definitions: updated_at == created_at.
539 agent.created_at,
540 // Phase 2 (hide templates): new rows start visible. The
541 // user only hides via the explicit `agent_def_hide` RPC,
542 // and the agent-seed re-sync forces user_hidden = 0 on
543 // any newly-added template id anyway, so honouring the
544 // caller-supplied value here is safe even when a stray
545 // 1 sneaks through.
546 agent.user_hidden,
547 agent.container_image,
548 agent.container_volumes,
549 agent.container_name,
550 ],
551 )?;
552 // Persist the stamped updated_at before we leave the lock so the
553 // dual-write helper sees the same value the SQL row carries.
554 let stamped_updated_at = agent.created_at;
555 drop(conn);
556 let mut snapshot = agent.clone();
557 snapshot.updated_at = stamped_updated_at;
558 // Mirror new definition into db_agents immediately.
559 self.agents_dual_write_definition_upsert(&snapshot)?;
560 // Mirror into the global cross-channel definition store. Content +
561 // skills are inserted after the definition (separate calls), so this
562 // initial record is content-less; agent_content_set / agent_skill_*
563 // re-mirror with the full payload. (P0.2b.)
564 self.registry_def_upsert(&agent.id);
565 // Reagent P1 on #1013 round 2: do NOT mutate the caller's
566 // `&mut AgentDefinition` here. The PR is supposed to be
567 // zero-behaviour-change; the previous version reflected
568 // `stamped_updated_at` back onto the caller, which is an
569 // observable mutation downstream callers may rely on remaining
570 // untouched. Callers that need the freshly-stamped value can
571 // re-fetch the row via the normal read path.
572 let _ = stamped_updated_at;
573 Ok(())
574 }
575
576 /// Atomic check-then-insert for `agent.define`.
577 ///
578 /// Looks up an existing user-owned definition by name (case-insensitive)
579 /// or derived slug under the SAME mutex guard that protects the INSERT —
580 /// preventing TOCTOU when two concurrent `agent.define` calls arrive for
581 /// the same name.
582 ///
583 /// Returns:
584 /// - `Ok(Some(def))` — an existing row matched; `agent` was NOT inserted.
585 /// - `Ok(None)` — no match; `agent` was inserted and `agent.slug` now
586 /// holds the collision-resolved slug.
587 pub fn agent_def_find_or_insert(
588 &self,
589 agent: &mut AgentDefinition,
590 ) -> Result<Option<AgentDefinition>, StoreError> {
591 let name_lower = agent.name.trim().to_lowercase();
592 let derived_slug = derive_slug(agent.name.trim());
593
594 let stamped_updated_at = {
595 let conn = self.conn.lock().unwrap();
596
597 // Check under the same lock to close the TOCTOU window.
598 let mut stmt = conn.prepare(
599 "SELECT id, slug, name, icon, provider, description,
600 working_directory, shell, provider_flags, auto_start,
601 restart_on_crash, idle_timeout_minutes, created_at,
602 agent_type, environment, agent_bus_id, is_seeded,
603 accounts, parent_id, branch_label, updated_at,
604 user_hidden, container_image, container_volumes, container_name
605 FROM db_agent_definitions
606 WHERE (lower(trim(name)) = ?1 OR slug = ?2)
607 AND is_seeded = 0
608 ORDER BY CASE WHEN lower(trim(name)) = ?1 THEN 0 ELSE 1 END
609 LIMIT 1",
610 )?;
611 let mut rows = stmt.query_map(
612 params![name_lower, derived_slug],
613 map_agent_definition_row,
614 )?;
615 if let Some(row) = rows.next() {
616 return Ok(Some(row?));
617 }
618 // Drop borrows on `conn` before proceeding to the insert.
619 drop(rows);
620 drop(stmt);
621
622 // Not found — insert under the same lock.
623 let base = if agent.slug.is_empty() {
624 derive_slug(&agent.name)
625 } else {
626 agent.slug.clone()
627 };
628 let mut candidate = base.clone();
629 let mut n: u32 = 2;
630 // Query db_agents (the superset view) for slug uniqueness — matches
631 // agent_def_insert at line 440. Template-instance projections add rows to
632 // db_agents without a corresponding db_agent_definitions entry; checking
633 // only db_agent_definitions would miss those slugs and allow collisions.
634 loop {
635 let count: i64 = conn.query_row(
636 "SELECT COUNT(*) FROM db_agents WHERE slug = ?1",
637 params![candidate],
638 |row| row.get(0),
639 )?;
640 if count == 0 {
641 break;
642 }
643 candidate = format!("{}-{}", base, n);
644 n += 1;
645 }
646 agent.slug = candidate;
647 conn.execute(
648 "INSERT INTO db_agent_definitions
649 (id, slug, name, icon, provider, description,
650 working_directory, shell, provider_flags, auto_start, restart_on_crash,
651 idle_timeout_minutes, created_at, agent_type, environment, agent_bus_id,
652 is_seeded, accounts, parent_id, branch_label, updated_at, user_hidden,
653 container_image, container_volumes, container_name)
654 VALUES
655 (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
656 ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)",
657 params![
658 agent.id, agent.slug, agent.name, agent.icon, agent.provider,
659 agent.description, agent.working_directory, agent.shell,
660 agent.provider_flags, agent.auto_start, agent.restart_on_crash,
661 agent.idle_timeout_minutes, agent.created_at, agent.agent_type,
662 agent.environment, agent.agent_bus_id, agent.is_seeded,
663 agent.accounts, agent.parent_id, agent.branch_label,
664 agent.created_at, // updated_at = created_at for new rows
665 agent.user_hidden,
666 agent.container_image, agent.container_volumes, agent.container_name,
667 ],
668 )?;
669 agent.created_at
670 // conn guard drops here; dual-write acquires the lock again below
671 };
672
673 let mut snapshot = agent.clone();
674 snapshot.updated_at = stamped_updated_at;
675 self.agents_dual_write_definition_upsert(&snapshot)?;
676 // Mirror the freshly-defined agent into the global store (agent.define
677 // path). Without this a define-created agent stays channel-local until
678 // a later edit. (codex P2 on #1385.)
679 self.registry_def_upsert(&agent.id);
680
681 Ok(None)
682 }
683
684 /// Set the `user_hidden` flag on a single agent definition. Phase 2
685 /// of the two-tier picker (Q2 Decision Y). Returns:
686 /// `Ok(true)` — row updated.
687 /// `Ok(false)` — no row with that id exists.
688 /// `Err(...)` — the row exists but is NOT a seeded template
689 /// (`is_seeded != 1`). User-owned definitions go
690 /// through `agent_def_delete`, not hide.
691 ///
692 /// Does NOT bump `updated_at`: hide is a per-user view-state flag,
693 /// not a definition-content edit. Keeps `updated_at` faithful to the
694 /// agent's payload (the manifest re-sync compares `description` etc.
695 /// against the canonical row).
696 pub fn agent_def_set_hidden(&self, id: &str, hidden: bool) -> Result<bool, StoreError> {
697 let conn = self.conn.lock().unwrap();
698 // Only seeded templates (`is_template = 1`) may flip the hide flag.
699 // Templates carry `is_template = 1` and are the only rows allowed
700 // to flip the hide flag; folded user-clone-def projections and
701 // template-instance projections (both `is_template = 0`) reject.
702 let is_template: i64 = match conn.query_row(
703 "SELECT is_template FROM db_agents WHERE id = ?1",
704 params![id],
705 |row| row.get(0),
706 ) {
707 Ok(v) => v,
708 Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(false),
709 Err(e) => return Err(StoreError::Sqlite(e)),
710 };
711 if is_template != 1 {
712 return Err(StoreError::Other(format!(
713 "agent_def_set_hidden: {id} is not a seeded template (is_template={is_template}); \
714 user-owned definitions must use delete/archive paths, not hide"
715 )));
716 }
717 // The hide flag is per-row; the write must still hit the legacy
718 // table (cascade source) — dual-write mirrors into `db_agents`
719 // for the next read. (Templates are seeded; they do NOT go to the
720 // global cross-channel def store, so no mirror here.)
721 let rows = conn.execute(
722 "UPDATE db_agent_definitions SET user_hidden = ?1 WHERE id = ?2",
723 params![if hidden { 1_i64 } else { 0_i64 }, id],
724 )?;
725 if rows > 0 {
726 // Mirror the flag into `db_agents` so the next read of the
727 // template projection sees the update without waiting for a
728 // round-trip through definition_upsert.
729 if let Err(e) = conn.execute(
730 "UPDATE db_agents SET user_hidden = ?1 WHERE id = ?2 AND is_template = 1",
731 params![if hidden { 1_i64 } else { 0_i64 }, id],
732 ) {
733 tracing::error!(
734 id = %id,
735 hidden,
736 error = %e,
737 "db_agents dual-write: template hide flag mirror failed",
738 );
739 }
740 }
741 Ok(rows > 0)
742 }
743
744 /// Update an existing agent definition (all fields except id, created_at, is_seeded).
745 /// `parent_id` and `branch_label` are NOT updatable post-insert — they
746 /// describe the agent's provenance; renaming or re-branching is done by
747 /// creating a new fork, not mutating the original.
748 ///
749 /// Self-stamps `updated_at` with the current time and writes it back into
750 /// `agent.updated_at`, so the caller's struct (e.g. an RPC response body)
751 /// reflects exactly what landed in the database.
752 pub fn agent_def_update(&self, agent: &mut AgentDefinition) -> Result<bool, StoreError> {
753 let now = std::time::SystemTime::now()
754 .duration_since(std::time::UNIX_EPOCH)
755 .unwrap_or_default()
756 .as_millis() as i64;
757 let rows = {
758 let conn = self.conn.lock().unwrap();
759 conn.execute(
760 "UPDATE db_agent_definitions SET name=?1, icon=?2, provider=?3, description=?4,
761 working_directory=?5, shell=?6, provider_flags=?7, auto_start=?8,
762 restart_on_crash=?9, idle_timeout_minutes=?10,
763 agent_type=?11, environment=?12, agent_bus_id=?13, accounts=?14, updated_at=?15,
764 container_image=?17, container_volumes=?18, container_name=?19
765 WHERE id=?16",
766 params![
767 agent.name,
768 agent.icon,
769 agent.provider,
770 agent.description,
771 agent.working_directory,
772 agent.shell,
773 agent.provider_flags,
774 agent.auto_start,
775 agent.restart_on_crash,
776 agent.idle_timeout_minutes,
777 agent.agent_type,
778 agent.environment,
779 agent.agent_bus_id,
780 agent.accounts,
781 now,
782 agent.id,
783 agent.container_image,
784 agent.container_volumes,
785 agent.container_name,
786 ],
787 )?
788 };
789 // Reflect the persisted timestamp back to the caller's struct so an
790 // RPC response carries the fresh value, not the pre-update one.
791 agent.updated_at = now;
792 // Mirror updated definition into db_agents.
793 if rows > 0 {
794 self.agents_dual_write_definition_upsert(agent)?;
795 // Mirror the updated definition into the global store. (P0.2b.)
796 self.registry_def_upsert(&agent.id);
797 }
798 // Cross-channel edit: an agent surfaced only via the global overlay has
799 // no local SQLite row, so the UPDATE affected 0 rows. Apply the edit to
800 // the global record directly (preserving its content/skills) so editing
801 // a cross-channel agent isn't silently dropped with a "not found" error
802 // — symmetric with the unconditional cross-channel delete. (reagent P1
803 // on #1385.)
804 let updated_global = if rows == 0 {
805 self.registry_def_update_definition_fields(agent)
806 } else {
807 false
808 };
809 Ok(rows > 0 || updated_global)
810 }
811
812 /// Delete a agent definition by id. Returns true if a row was deleted.
813 pub fn agent_def_delete(&self, id: &str) -> Result<bool, StoreError> {
814 // Snapshot cascaded instance ids AND issue the parent DELETE
815 // under one lock acquisition so no thread can `instance_create`
816 // a new row for this definition between the two steps. The
817 // SQL DELETE's FK cascade fires inside SQLite while we hold
818 // the mutex, so the snapshot exactly matches the rows that
819 // got removed by the cascade.
820 let (cascaded_instance_ids, rows) = {
821 let conn = self.conn.lock().unwrap();
822 let cascaded_instance_ids: Vec<String> = {
823 let mut stmt = conn
824 .prepare("SELECT id FROM db_agent_instances WHERE definition_id = ?1")?;
825 let iter = stmt.query_map(params![id], |row| row.get::<_, String>(0))?;
826 iter.collect::<Result<Vec<_>, _>>()?
827 };
828 let rows = conn.execute(
829 "DELETE FROM db_agent_definitions WHERE id=?1",
830 params![id],
831 )?;
832 (cascaded_instance_ids, rows)
833 };
834 if rows > 0 {
835 if let Some(reg) = self.registry() {
836 for instance_id in &cascaded_instance_ids {
837 if let Err(e) = reg.hard_delete(instance_id) {
838 tracing::warn!(
839 instance_id = %instance_id,
840 agent_def_id = %id,
841 error = %e,
842 "registry: failed to mirror agent_def_delete cascade"
843 );
844 }
845 }
846 }
847 // Mirror deleted definition out of db_agents.
848 self.agents_dual_write_definition_delete(id)?;
849 for instance_id in &cascaded_instance_ids {
850 self.agents_dual_write_instance_delete(instance_id)?;
851 }
852 }
853 // Tombstone the global definition record so another channel's stale
854 // SQLite can't resurrect this deleted user agent — AND so an agent
855 // that exists in THIS channel only via the global overlay (no local
856 // SQLite row, rows == 0) is actually deletable instead of reappearing
857 // on the next agent_def_list. (P0.2b + codex P1 on #1385.)
858 let global_retired = self.registry_def_retire(id);
859 Ok(rows > 0 || global_retired)
860 }
861
862 // AgentContent / AgentSkill / AgentHistory CRUD live in
863 // `super::content` / `super::skills` / `super::history` —
864 // each adds an `impl Store {}` block to this type.
865}
866
867impl Store {
868
869 // ---- Agent instance CRUD ----
870
871 /// List instances. Both filters are optional — pass `None` to scan
872 /// all instances. Ordered by `updated_at` descending, with
873 /// `created_at` as a tiebreaker (most recent activity first; the
874 /// dual-write bumps `updated_at` on every launch / continuation,
875 /// so a continued older agent ranks ahead of a brand-new untouched
876 /// one).
877 ///
878 /// Reads from the consolidated `db_agents` table (`is_template = 0`,
879 /// `user_hidden = 0`) for the no-status case.
880 /// Continuation chains pre-collapse — one row per logical agent.
881 /// The `definition_id` filter, when supplied, matches the agent's
882 /// own `id` only (templates aren't agents and user-clones derived
883 /// from a template are SEPARATE agents — see the implementation
884 /// note below for why `parent_template_id` traversal was dropped).
885 ///
886 /// Field mapping for fields with no consolidated-row analog:
887 /// - `block_id`, `session_id`, `status`, `ended_at`,
888 /// `parent_instance_id` → type defaults (`""` / `0`). Truly
889 /// transient per-launch state; not modelled on `db_agents`.
890 /// - `started_at` → `db_agents.created_at`. Same proxy used by
891 /// `instance_get_by_name` (3b.2); the consolidated row's
892 /// creation IS the agent's launch moment in the new model.
893 /// - `display_hidden` → always `false`. The WHERE clause filters
894 /// `user_hidden = 0`, so hidden rows never surface here.
895 ///
896 /// Callers passing a
897 /// `status` filter need transient runtime state that `db_agents`
898 /// doesn't model. Route those to the legacy `db_agent_instances`
899 /// path so existing semantics are preserved until the
900 /// updateagentinstance handler's "fetch + merge transient fields"
901 /// pattern is refactored. (Currently no production caller passes
902 /// `status` — `listagentinstances` RPC frontends call with empty
903 /// filters — so the legacy path is exercised only by tests.)
904 /// Spec: docs/specs/SPEC_AGENT_ARCHITECTURE_2026_05_27.md §3b.3.
905 pub fn instance_list(
906 &self,
907 definition_id: Option<&str>,
908 status: Option<&str>,
909 ) -> Result<Vec<AgentInstance>, StoreError> {
910 if status.is_some() {
911 return self.instance_list_legacy(definition_id, status);
912 }
913 let conn = self.conn.lock().unwrap();
914 // `definition_id` filter: match the agent's own `id` only.
915 //
916 // In the consolidated model every `is_template = 0` row IS an
917 // agent identifiable by its `id`:
918 // - User-clone def projection: `id` = the clone def's id.
919 // - Template-instance projection: `id` = the original inst.id.
920 //
921 // The legacy `definition_id` filter conflated "agent identity"
922 // with "parent template" because the old schema split them
923 // across two tables. The new model has no such split. A
924 // template-id filter would over-match (user-clones of X share
925 // `parent_template_id` with template-instances of X — see
926 // codex P2 on PR #1111), so we route template-id callers to
927 // an empty result. No live caller exercises that path; the
928 // only frontend consumer (`listagentinstances` RPC via
929 // swarm-model) passes empty filters.
930 let mut filter_clause = String::new();
931 let mut param_vals: Vec<String> = Vec::new();
932 if let Some(d) = definition_id {
933 filter_clause.push_str("\n AND id = ?1");
934 param_vals.push(d.to_string());
935 }
936 // Projection for `definition_id`: use the row's own id.
937 //
938 // Reagent P2 on PR #1111 round 2: the earlier "parent_template_id
939 // with id fallback" projection diverged from legacy for
940 // user-clones derived from a template — those rows have
941 // `parent_template_id` SET (the lineage), but their legacy
942 // `db_agent_instances.definition_id` was the clone's own def
943 // id, not the template's. Template-instance projections and
944 // user-clone projections aren't schema-distinguishable in
945 // db_agents (both `is_template = 0`, `is_seeded = 0`), so any
946 // consistent projection must pick one rule. The consolidated
947 // model treats the row's `id` as the agent's identity (== legacy
948 // `definition_id` for the user-clone case), so use that. The
949 // template-instance case yields the inst.id instead of the
950 // template id, but no live caller depends on this field — it's
951 // a back-compat surface for the AgentInstance struct shape.
952 //
953 // `ORDER BY updated_at DESC`: reagent P2 on PR #1111 round 2.
954 // Continuation chains keep the head's `created_at` (the row is
955 // never re-inserted) while the dual-write bumps `updated_at` on
956 // every launch / continuation. So `created_at DESC` would rank
957 // a brand-new agent ahead of an actively-continued older one,
958 // violating "most recent activity first". `updated_at` tracks
959 // recency correctly and matches the ordering `agent_def_list`
960 // uses elsewhere in this file.
961 let mut sql = String::from(
962 "SELECT id, id AS def_id,
963 github_context, created_at,
964 identity_id, memory_id, instance_name, working_directory
965 FROM db_agents
966 WHERE is_template = 0
967 AND user_hidden = 0",
968 );
969 sql.push_str(&filter_clause);
970 sql.push_str("\n ORDER BY updated_at DESC, created_at DESC");
971 let mut stmt = conn.prepare(&sql)?;
972 let iter = stmt.query_map(rusqlite::params_from_iter(param_vals.iter()), |row| {
973 Ok(AgentInstance {
974 id: row.get(0)?,
975 definition_id: row.get(1)?,
976 parent_instance_id: String::new(),
977 block_id: String::new(),
978 session_id: String::new(),
979 status: String::new(),
980 github_context: row.get(2)?,
981 started_at: row.get(3)?,
982 ended_at: 0,
983 created_at: row.get(3)?,
984 identity_id: row.get(4)?,
985 memory_id: row.get(5)?,
986 instance_name: row.get(6)?,
987 working_directory: row.get(7)?,
988 // Filter above guarantees the row is visible; column
989 // omitted from SELECT to match. Reagent P2 on PR #1111.
990 display_hidden: false,
991 })
992 })?;
993 let mut out = Vec::new();
994 for r in iter {
995 out.push(r?);
996 }
997 Ok(out)
998 }
999
1000 /// Legacy `db_agent_instances` read — preserved for the
1001 /// status-filter case (transient state). Will retire when the
1002 /// updateagentinstance handler's fetch-and-merge pattern is
1003 /// refactored. Do NOT add new callers; use
1004 /// `instance_list` instead.
1005 fn instance_list_legacy(
1006 &self,
1007 definition_id: Option<&str>,
1008 status: Option<&str>,
1009 ) -> Result<Vec<AgentInstance>, StoreError> {
1010 let conn = self.conn.lock().unwrap();
1011 let mut sql = String::from(
1012 "SELECT id, definition_id, parent_instance_id, block_id, session_id,
1013 status, github_context, started_at, ended_at, created_at,
1014 identity_id, memory_id, instance_name, working_directory,
1015 display_hidden
1016 FROM db_agent_instances",
1017 );
1018 let mut clauses: Vec<&str> = Vec::new();
1019 if definition_id.is_some() {
1020 clauses.push("definition_id = ?");
1021 }
1022 if status.is_some() {
1023 clauses.push("status = ?");
1024 }
1025 if !clauses.is_empty() {
1026 sql.push_str(" WHERE ");
1027 sql.push_str(&clauses.join(" AND "));
1028 }
1029 sql.push_str(" ORDER BY created_at DESC");
1030
1031 let mut stmt = conn.prepare(&sql)?;
1032 let mut param_vals: Vec<String> = Vec::new();
1033 if let Some(d) = definition_id {
1034 param_vals.push(d.to_string());
1035 }
1036 if let Some(s) = status {
1037 param_vals.push(s.to_string());
1038 }
1039 let iter = stmt.query_map(rusqlite::params_from_iter(param_vals.iter()), map_instance_row)?;
1040 let mut out = Vec::new();
1041 for r in iter {
1042 out.push(r?);
1043 }
1044 Ok(out)
1045 }
1046
1047 pub fn instance_get(&self, id: &str) -> Result<Option<AgentInstance>, StoreError> {
1048 let conn = self.conn.lock().unwrap();
1049 let mut stmt = conn.prepare(
1050 "SELECT id, definition_id, parent_instance_id, block_id, session_id,
1051 status, github_context, started_at, ended_at, created_at,
1052 identity_id, memory_id, instance_name, working_directory,
1053 display_hidden
1054 FROM db_agent_instances WHERE id = ?1",
1055 )?;
1056 let result = stmt.query_row(params![id], map_instance_row);
1057 match result {
1058 Ok(a) => Ok(Some(a)),
1059 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1060 Err(e) => Err(e.into()),
1061 }
1062 }
1063
1064 /// Insert a new instance row. Caller is responsible for the id (UUID).
1065 pub fn instance_create(&self, inst: &AgentInstance) -> Result<(), StoreError> {
1066 {
1067 let conn = self.conn.lock().unwrap();
1068 conn.execute(
1069 "INSERT INTO db_agent_instances
1070 (id, definition_id, parent_instance_id, block_id, session_id, status,
1071 github_context, started_at, ended_at, created_at,
1072 identity_id, memory_id,
1073 instance_name, working_directory, display_hidden)
1074 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
1075 ?13, ?14, ?15)",
1076 params![
1077 inst.id,
1078 inst.definition_id,
1079 inst.parent_instance_id,
1080 inst.block_id,
1081 inst.session_id,
1082 inst.status,
1083 inst.github_context,
1084 inst.started_at,
1085 inst.ended_at,
1086 inst.created_at,
1087 inst.identity_id,
1088 inst.memory_id,
1089 inst.instance_name,
1090 inst.working_directory,
1091 if inst.display_hidden { 1_i64 } else { 0_i64 },
1092 ],
1093 )?;
1094 }
1095 self.registry_upsert_if_named(inst);
1096 // Mirror new instance into db_agents.
1097 self.agents_dual_write_instance_create(inst)?;
1098 Ok(())
1099 }
1100
1101 /// Set the `display_hidden` flag on an existing instance row. Used
1102 /// by the "Forget agent" affordance — soft-delete only; the row +
1103 /// working directory remain on disk for audit + recovery.
1104 ///
1105 /// Cross-version case: an agent migrated into the registry from
1106 /// another version's SQLite won't have a row in the current
1107 /// version's SQLite. The UPDATE returns 0 rows, but the registry
1108 /// still needs to flip — otherwise "Forget agent" silently no-ops
1109 /// on cross-version entries. Returns `true` if either side acted.
1110 pub fn instance_set_hidden(&self, id: &str, hidden: bool) -> Result<bool, StoreError> {
1111 let rows = {
1112 let conn = self.conn.lock().unwrap();
1113 conn.execute(
1114 "UPDATE db_agent_instances SET display_hidden = ?1 WHERE id = ?2",
1115 params![if hidden { 1_i64 } else { 0_i64 }, id],
1116 )?
1117 };
1118 let mut registry_acted = false;
1119 if let Some(reg) = self.registry() {
1120 // Only act on the registry side if a record exists there
1121 // (in either active or retired). Avoids logging spurious
1122 // "failed to retire" warnings on a no-op for unrelated ids.
1123 if reg.exists_anywhere(id) {
1124 let res = if hidden {
1125 reg.retire(id)
1126 } else {
1127 reg.unretire(id)
1128 };
1129 match res {
1130 Ok(()) => registry_acted = true,
1131 Err(e) => tracing::warn!(
1132 instance_id = %id,
1133 hidden,
1134 error = %e,
1135 "registry: failed to mirror instance_set_hidden"
1136 ),
1137 }
1138 }
1139 }
1140 // Flip the hidden bit on db_agents.
1141 self.agents_dual_write_instance_set_hidden(id, hidden)?;
1142 Ok(rows > 0 || registry_acted)
1143 }
1144
1145 /// List named instances for the launch-modal "Continue agent"
1146 /// dropdown (`include_continuations = false`) or the picker
1147 /// "My Agents" surface (`include_continuations = true`). Filters
1148 /// to non-hidden + named rows, sorted by `started_at DESC`,
1149 /// capped by `limit`.
1150 ///
1151 /// `definition_id`, when provided, restricts the result to
1152 /// instances of that definition. Server-side filtering is
1153 /// necessary because the launch modal opens per-definition: a
1154 /// user with 200+ named agents across many definitions could
1155 /// have the current definition's older instances cut off by a
1156 /// purely global limit otherwise.
1157 ///
1158 /// `include_continuations` controls whether rows with
1159 /// `parent_instance_id != ''` (continuation chains) are
1160 /// returned:
1161 ///
1162 /// - **`false`** (legacy "head-of-chain only"). Pre-Option-E
1163 /// semantics: hides continuation rows so the launch-modal
1164 /// dropdown shows one entry per chain root. `listnamedagents`
1165 /// ALSO uses this mode for its same-version SQLite enrichment
1166 /// of registry-sourced rows — the registry mirror filter at
1167 /// `registry_upsert_if_named` excludes continuations
1168 /// symmetrically, and breaking that symmetry under a `limit`
1169 /// truncation would let continuation rows displace
1170 /// registry-head rows in the top-N and miss the
1171 /// merge-by-id enrichment. (Codex P1 on PR #1016 first cut:
1172 /// regresses running-state badges and focus-existing-pane
1173 /// hints for any user whose latest instance is a continuation.)
1174 ///
1175 /// - **`true`** (Option-E "include continuations"). For the
1176 /// picker's `listrecentsessions` flow and the
1177 /// `template_promote` migration's instance-name lookup. Under
1178 /// Option E the session zone is anchored on `definition_id`,
1179 /// so a continuation row is simply the most-recent named
1180 /// instance of an agent the user actively used — exactly
1181 /// what those callers want visible. Excluding them hides
1182 /// real agents (the original 2026-05-24 "Maks doesn't appear
1183 /// under My Agents" report) and makes `template_promote`'s
1184 /// name lookup miss the real `instance_name`, falling back
1185 /// to the template name.
1186 pub fn instance_list_named(
1187 &self,
1188 limit: usize,
1189 definition_id: Option<&str>,
1190 identity_id: Option<&str>,
1191 include_continuations: bool,
1192 ) -> Result<Vec<AgentInstance>, StoreError> {
1193 let conn = self.conn.lock().unwrap();
1194
1195 // Dynamic bind-index assignment. We accumulate optional
1196 // string params into `extra_params` in the same order they
1197 // appear in the SQL, then bind the limit last.
1198 let mut extra_params: Vec<&str> = Vec::with_capacity(2);
1199
1200 if !include_continuations {
1201 // Legacy "dropdown" mode: only chain heads (no parent).
1202 // Used by the launch modal's `Continue agent` dropdown +
1203 // the registry-enrichment path. Symmetric with
1204 // `registry_upsert_if_named` — chains show up as one
1205 // head entry, not N entries per resume.
1206 let mut sql = String::from(
1207 "SELECT id, definition_id, parent_instance_id, block_id, session_id,
1208 status, github_context, started_at, ended_at, created_at,
1209 identity_id, memory_id, instance_name, working_directory,
1210 display_hidden
1211 FROM db_agent_instances
1212 WHERE display_hidden = 0
1213 AND instance_name <> ''
1214 AND parent_instance_id = ''",
1215 );
1216 let mut next_idx = 1usize;
1217 if let Some(id) = identity_id {
1218 sql.push_str(&format!("\n AND identity_id = ?{}", next_idx));
1219 extra_params.push(id);
1220 next_idx += 1;
1221 }
1222 if let Some(def) = definition_id {
1223 sql.push_str(&format!("\n AND definition_id = ?{}", next_idx));
1224 extra_params.push(def);
1225 next_idx += 1;
1226 }
1227 sql.push_str(&format!(
1228 "\n ORDER BY started_at DESC\n LIMIT ?{}",
1229 next_idx
1230 ));
1231 let mut stmt = conn.prepare(&sql)?;
1232 let limit_i64 = limit as i64;
1233 let mut bindings: Vec<&dyn rusqlite::ToSql> =
1234 Vec::with_capacity(extra_params.len() + 1);
1235 for s in &extra_params {
1236 bindings.push(s);
1237 }
1238 bindings.push(&limit_i64);
1239 let iter = stmt.query_map(bindings.as_slice(), map_instance_row)?;
1240 let mut out = Vec::new();
1241 for r in iter {
1242 out.push(r?);
1243 }
1244 return Ok(out);
1245 }
1246
1247 // Picker ("My Agents") mode: dedupe continuation chains so
1248 // each logical agent surfaces as exactly one row — the most
1249 // recent in its chain (by `started_at`, tiebreaker `id`).
1250 //
1251 // Before this dedup, a user with 5 continuations of one
1252 // logical agent saw 5 entries in My Agents. See discussion
1253 // #1095 / `docs/specs/SPEC_AGENT_ARCHITECTURE_2026_05_27.md`.
1254 //
1255 // Mechanics:
1256 // - A recursive CTE walks `parent_instance_id` from each
1257 // head (parent_instance_id = '') down to its
1258 // descendants, stamping every row with the head's
1259 // `root_id`.
1260 // - `ROW_NUMBER() OVER (PARTITION BY root_id ORDER BY
1261 // started_at DESC, id DESC)` picks the latest row per
1262 // chain. The id tiebreaker keeps the ordering
1263 // deterministic when two rows share `started_at` (only
1264 // happens in tests / on adjacent inserts).
1265 // - **Hidden filter must run AFTER ranking.** Otherwise
1266 // `hidenamedagent` becomes a no-op for any chain with
1267 // older visible siblings: the SQL excludes the hidden
1268 // row before ranking, so the next-newest visible row
1269 // inherits `rn = 1` and the "forgotten" agent
1270 // immediately reappears in the picker. Codex P2 on PR
1271 // #1096. By ranking first and filtering
1272 // `display_hidden` last, hiding the surfaced row
1273 // suppresses the whole chain — exactly what the user's
1274 // forget action means.
1275 // - The unnamed-row filter (`instance_name <> ''`) stays
1276 // pre-rank: an unnamed continuation row should never
1277 // win, but also shouldn't influence chain ranking — it
1278 // simply isn't a candidate.
1279 let mut sql = String::from(
1280 r#"WITH RECURSIVE
1281 roots(id, definition_id, parent_instance_id, block_id, session_id,
1282 status, github_context, started_at, ended_at, created_at,
1283 identity_id, memory_id, instance_name, working_directory,
1284 display_hidden, root_id) AS (
1285 -- Anchor: a row is its own root if it has no parent OR
1286 -- its parent no longer exists in the table. The latter
1287 -- case (orphan continuation) happens when
1288 -- `deleteagentinstance` hard-deletes a chain head —
1289 -- there's no FK cascade, so descendant rows remain. If
1290 -- we seeded only from `parent_instance_id = ''`,
1291 -- orphans would be unreachable by the recursive walk
1292 -- and disappear from My Agents even though they're
1293 -- recoverable sessions. Codex P2 on PR #1096
1294 -- bbe897cc → orphan-as-root anchor.
1295 SELECT id, definition_id, parent_instance_id, block_id, session_id,
1296 status, github_context, started_at, ended_at, created_at,
1297 identity_id, memory_id, instance_name, working_directory,
1298 display_hidden,
1299 id
1300 FROM db_agent_instances p
1301 WHERE p.parent_instance_id = ''
1302 OR NOT EXISTS (
1303 SELECT 1 FROM db_agent_instances q
1304 WHERE q.id = p.parent_instance_id
1305 )
1306 UNION ALL
1307 SELECT c.id, c.definition_id, c.parent_instance_id, c.block_id,
1308 c.session_id, c.status, c.github_context, c.started_at,
1309 c.ended_at, c.created_at, c.identity_id, c.memory_id,
1310 c.instance_name, c.working_directory, c.display_hidden,
1311 r.root_id
1312 FROM db_agent_instances c
1313 JOIN roots r ON c.parent_instance_id = r.id
1314 ),
1315 ranked AS (
1316 SELECT id, definition_id, parent_instance_id, block_id, session_id,
1317 status, github_context, started_at, ended_at, created_at,
1318 identity_id, memory_id, instance_name, working_directory,
1319 display_hidden, root_id,
1320 ROW_NUMBER() OVER (
1321 PARTITION BY root_id
1322 ORDER BY started_at DESC, id DESC
1323 ) AS rn
1324 FROM roots
1325 WHERE instance_name <> ''"#
1326 .to_string(),
1327 );
1328 // Identity filter MUST run inside the `ranked` CTE (i.e.,
1329 // before ROW_NUMBER) so the newest row matching the requested
1330 // identity per chain wins. If we filtered identity in the
1331 // outer SELECT instead, a chain whose newest row uses a
1332 // different identity would be dropped even if an older row in
1333 // the chain matched. Codex P2 #3 on PR #1096 0c4c8c46.
1334 let mut next_idx = 1usize;
1335 if let Some(id) = identity_id {
1336 sql.push_str(&format!("\n AND identity_id = ?{}", next_idx));
1337 extra_params.push(id);
1338 next_idx += 1;
1339 }
1340 sql.push_str(
1341 r#"
1342 )
1343 SELECT id, definition_id, parent_instance_id, block_id, session_id,
1344 status, github_context, started_at, ended_at, created_at,
1345 identity_id, memory_id, instance_name, working_directory,
1346 display_hidden
1347 FROM ranked
1348 WHERE rn = 1
1349 AND display_hidden = 0"#,
1350 );
1351 if let Some(def) = definition_id {
1352 sql.push_str(&format!("\n AND definition_id = ?{}", next_idx));
1353 extra_params.push(def);
1354 next_idx += 1;
1355 }
1356 sql.push_str(&format!(
1357 "\n ORDER BY started_at DESC\n LIMIT ?{}",
1358 next_idx
1359 ));
1360 let mut stmt = conn.prepare(&sql)?;
1361 let limit_i64 = limit as i64;
1362 let mut bindings: Vec<&dyn rusqlite::ToSql> =
1363 Vec::with_capacity(extra_params.len() + 1);
1364 for s in &extra_params {
1365 bindings.push(s);
1366 }
1367 bindings.push(&limit_i64);
1368 let iter = stmt.query_map(bindings.as_slice(), map_instance_row)?;
1369 let mut out = Vec::new();
1370 for r in iter {
1371 out.push(r?);
1372 }
1373 Ok(out)
1374 }
1375
1376 /// Look up the canonical named-agent row matching `instance_name`.
1377 /// Used by the launch modal to detect name collisions ("did you
1378 /// mean to continue?") and by `ContinueNamedAgentCommand` to
1379 /// resolve the consolidated row when the caller only knows the
1380 /// name. Hidden rows are excluded.
1381 ///
1382 /// Reads from the consolidated `db_agents` table —
1383 /// `is_template = 0` (named user agent), `instance_name` matches,
1384 /// `user_hidden = 0`. Continuation chains are pre-collapsed in
1385 /// `db_agents` (one row per logical agent), so this returns the
1386 /// canonical agent regardless of how many launches its chain has.
1387 /// MRU tiebreak is by `updated_at` (the dual-write touches it on
1388 /// every continuation), then `created_at` for stable order.
1389 ///
1390 /// The legacy `db_agent_instances` carried per-launch runtime
1391 /// state (`block_id`, `session_id`, `status`, `started_at`,
1392 /// `ended_at`, `parent_instance_id`) that has no analog in
1393 /// `db_agents` — those fields are returned as their `AgentInstance`
1394 /// defaults (empty strings, 0). Callers wanting transient state
1395 /// should consult runtime sources (the controller, the block
1396 /// row); none of the documented use cases need it (collision
1397 /// detection only cares about identity / cwd; ContinueNamed only
1398 /// cares about `id` + bindings).
1399 /// Spec: docs/specs/SPEC_AGENT_ARCHITECTURE_2026_05_27.md §3b.
1400 pub fn instance_get_by_name(
1401 &self,
1402 instance_name: &str,
1403 ) -> Result<Option<AgentInstance>, StoreError> {
1404 if instance_name.is_empty() {
1405 return Ok(None);
1406 }
1407 let conn = self.conn.lock().unwrap();
1408 // `definition_id` projection: use the row's own `id`.
1409 //
1410 // The earlier "parent_template_id with id fallback" rule
1411 // misrendered the legacy semantics for user-clones derived
1412 // from a template — those rows have `parent_template_id` SET
1413 // (lineage record) but their legacy `definition_id` was the
1414 // clone's own def id, NOT the template's. Template-instance
1415 // and user-clone projections aren't schema-distinguishable in
1416 // db_agents, so any consistent rule must pick one. The
1417 // consolidated model treats `id` as the agent's identity, so
1418 // that's what we expose — matches `instance_list` (3b.3a)
1419 // after the same fix. Reagent P2 on PR #1111 round 2.
1420 let mut stmt = conn.prepare(
1421 "SELECT id, id AS def_id,
1422 github_context, created_at,
1423 identity_id, memory_id, instance_name, working_directory,
1424 user_hidden
1425 FROM db_agents
1426 WHERE instance_name = ?1
1427 AND is_template = 0
1428 AND user_hidden = 0
1429 ORDER BY updated_at DESC, created_at DESC
1430 LIMIT 1",
1431 )?;
1432 let result = stmt.query_row(params![instance_name], |row| {
1433 Ok(AgentInstance {
1434 id: row.get(0)?,
1435 definition_id: row.get(1)?,
1436 parent_instance_id: String::new(),
1437 block_id: String::new(),
1438 session_id: String::new(),
1439 status: String::new(),
1440 github_context: row.get(2)?,
1441 started_at: row.get(3)?, // created_at — best proxy for the consolidated row
1442 ended_at: 0,
1443 created_at: row.get(3)?,
1444 identity_id: row.get(4)?,
1445 memory_id: row.get(5)?,
1446 instance_name: row.get(6)?,
1447 working_directory: row.get(7)?,
1448 display_hidden: row.get::<_, i64>(8)? != 0,
1449 })
1450 });
1451 match result {
1452 Ok(a) => Ok(Some(a)),
1453 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1454 Err(e) => Err(e.into()),
1455 }
1456 }
1457
1458 /// Partial update of an instance's mutable runtime fields. Only
1459 /// `Some` fields are written; `None` leaves that column untouched.
1460 ///
1461 /// Replaces the `updateagentinstance` handler's fetch-and-merge
1462 /// (read the full row → fill the unspecified fields → write the
1463 /// whole struct back). That read was the only production caller
1464 /// that needed `instance_get`'s transient per-launch fields, which
1465 /// pinned `instance_get` to the legacy `db_agent_instances` table.
1466 /// With a partial write the handler no longer reads the row at all.
1467 /// See docs/specs/SPEC_UPDATEAGENTINSTANCE_PARTIAL_UPDATE_2026_05_29.md.
1468 ///
1469 /// Returns the post-update row so callers that need `definition_id`
1470 /// for an event scope — or want to echo the row back — get it from
1471 /// the same authoritative reload this method already runs to refresh
1472 /// the registry mirror + dual-write. Those consumers read
1473 /// only non-transient fields, so the reload survives a future
1474 /// `instance_get` → db_agents flip.
1475 ///
1476 /// `None` is reserved for **not-found** (the id doesn't exist). An
1477 /// all-`None` update on an existing id is a no-op that returns the
1478 /// unchanged row — so callers can distinguish "nothing to change"
1479 /// from "no such instance".
1480 pub fn instance_update_partial(
1481 &self,
1482 id: &str,
1483 upd: &InstanceUpdate,
1484 ) -> Result<Option<AgentInstance>, StoreError> {
1485 use rusqlite::types::ToSql;
1486
1487 let mut sets: Vec<&str> = Vec::new();
1488 let mut vals: Vec<Box<dyn ToSql>> = Vec::new();
1489 if let Some(v) = &upd.block_id {
1490 sets.push("block_id = ?");
1491 vals.push(Box::new(v.clone()));
1492 }
1493 if let Some(v) = &upd.session_id {
1494 sets.push("session_id = ?");
1495 vals.push(Box::new(v.clone()));
1496 }
1497 if let Some(v) = &upd.status {
1498 sets.push("status = ?");
1499 vals.push(Box::new(v.clone()));
1500 }
1501 if let Some(v) = &upd.github_context {
1502 // `Some("")` explicitly clears (matches the command contract).
1503 sets.push("github_context = ?");
1504 vals.push(Box::new(v.clone()));
1505 }
1506 if let Some(v) = upd.ended_at {
1507 sets.push("ended_at = ?");
1508 vals.push(Box::new(v));
1509 }
1510 if sets.is_empty() {
1511 // No fields to write. Return the current row unchanged (or
1512 // `None` if the id genuinely doesn't exist) so the caller can
1513 // still tell a no-op apart from not-found — rather than
1514 // conflating both as `None`. No write, no registry/dual-write
1515 // reload needed since nothing changed.
1516 return self.instance_get(id);
1517 }
1518
1519 let rows = {
1520 let conn = self.conn.lock().unwrap();
1521 let sql = format!(
1522 "UPDATE db_agent_instances SET {} WHERE id = ?",
1523 sets.join(", ")
1524 );
1525 vals.push(Box::new(id.to_string()));
1526 let params: Vec<&dyn ToSql> = vals.iter().map(|b| b.as_ref()).collect();
1527 conn.execute(&sql, params.as_slice())?
1528 };
1529 if rows == 0 {
1530 return Ok(None);
1531 }
1532 // Reload the authoritative post-update row and refresh the
1533 // registry mirror + dual-write — identical to `instance_update`.
1534 let fresh = self.instance_get(id)?;
1535 if let Some(f) = &fresh {
1536 self.registry_upsert_if_named(f);
1537 // Mirror update to db_agents.
1538 self.agents_dual_write_instance_update(f)?;
1539 }
1540 Ok(fresh)
1541 }
1542
1543 /// Update mutable instance fields. `id`, `definition_id`,
1544 /// `parent_instance_id`, `started_at`, `created_at` are immutable
1545 /// after insert (they describe provenance, not state).
1546 ///
1547 /// Retained as a full-struct convenience for store tests + internal
1548 /// callers; the `updateagentinstance` handler now uses
1549 /// [`Self::instance_update_partial`] so it no longer reads the row
1550 /// to merge.
1551 pub fn instance_update(&self, inst: &AgentInstance) -> Result<bool, StoreError> {
1552 let rows = {
1553 let conn = self.conn.lock().unwrap();
1554 conn.execute(
1555 "UPDATE db_agent_instances SET
1556 block_id = ?1,
1557 session_id = ?2,
1558 status = ?3,
1559 github_context = ?4,
1560 ended_at = ?5
1561 WHERE id = ?6",
1562 params![
1563 inst.block_id,
1564 inst.session_id,
1565 inst.status,
1566 inst.github_context,
1567 inst.ended_at,
1568 inst.id,
1569 ],
1570 )?
1571 };
1572 if rows > 0 {
1573 // Refresh the registry from the post-update authoritative row.
1574 // `inst` is the caller's pre-update view; SQL UPDATE only
1575 // touches a subset of fields, so we reload to keep registry
1576 // mirror exact.
1577 if let Ok(Some(fresh)) = self.instance_get(&inst.id) {
1578 self.registry_upsert_if_named(&fresh);
1579 // Mirror status fields to db_agents.
1580 self.agents_dual_write_instance_update(&fresh)?;
1581 }
1582 }
1583 Ok(rows > 0)
1584 }
1585
1586 /// Repoint every instance currently referencing `old_def_id` to
1587 /// `new_def_id`. Used by the Phase 1 two-tier-picker migration
1588 /// (SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md): when a seeded
1589 /// template has been used directly (carries an `agent:<id>:current`
1590 /// zone), the migration clones the template into a user agent and
1591 /// repoints any instances so the existing reattach flow
1592 /// (`continueOfInstanceId`) keeps working against the new
1593 /// definition_id. Returns the number of rows updated.
1594 ///
1595 /// `definition_id` is declared immutable post-insert on the normal
1596 /// `instance_update` path. This is the migration escape hatch.
1597 pub fn instance_repoint_definition(
1598 &self,
1599 old_def_id: &str,
1600 new_def_id: &str,
1601 ) -> Result<usize, StoreError> {
1602 let rows = {
1603 let conn = self.conn.lock().unwrap();
1604 conn.execute(
1605 "UPDATE db_agent_instances SET definition_id = ?1 WHERE definition_id = ?2",
1606 params![new_def_id, old_def_id],
1607 )?
1608 };
1609 // Re-aim parent_template_id on user-clone projection rows in db_agents.
1610 if rows > 0 {
1611 self.agents_dual_write_instance_repoint(old_def_id, new_def_id)?;
1612 }
1613 Ok(rows)
1614 }
1615
1616 pub fn instance_delete(&self, id: &str) -> Result<bool, StoreError> {
1617 let rows = {
1618 let conn = self.conn.lock().unwrap();
1619 conn.execute("DELETE FROM db_agent_instances WHERE id = ?1", params![id])?
1620 };
1621 if rows > 0 {
1622 if let Some(reg) = self.registry() {
1623 if let Err(e) = reg.hard_delete(id) {
1624 tracing::warn!(
1625 instance_id = %id,
1626 error = %e,
1627 "registry: failed to mirror instance_delete"
1628 );
1629 }
1630 }
1631 // Drop the user-clone projection from db_agents.
1632 self.agents_dual_write_instance_delete(id)?;
1633 }
1634 Ok(rows > 0)
1635 }
1636
1637 /// Back-fill `db_agent_instances.identity_id` for legacy rows that
1638 /// have either the empty string (post-v7 default before the launch
1639 /// modal required Identity) or the literal `"blank"` sentinel
1640 /// (pre-v8 placeholder for "use ambient creds"). Both shapes map
1641 /// to "no Identity bundle assigned" and the OAuth-bundles startup
1642 /// migration (PR E, spec §5) routes them to the newly-seeded
1643 /// Default bundle so the resolver can inject env vars from the
1644 /// captured ambient credentials at the next spawn.
1645 ///
1646 /// Returns the number of rows touched. Caller must verify that
1647 /// `new_identity_id` is a real `db_identity_bundles.id` — this
1648 /// method does NOT enforce FK validity (the column has no FK
1649 /// constraint per the v7 migration). Mis-use would orphan the
1650 /// rows to a non-existent bundle; the OAuth-bundles migration
1651 /// guards against this by only calling here when it just upserted
1652 /// the bundle row.
1653 pub fn instance_backfill_identity_id(
1654 &self,
1655 new_identity_id: &str,
1656 ) -> Result<usize, StoreError> {
1657 let rows = {
1658 let conn = self.conn.lock().unwrap();
1659 conn.execute(
1660 "UPDATE db_agent_instances
1661 SET identity_id = ?1
1662 WHERE identity_id = '' OR identity_id = 'blank'",
1663 params![new_identity_id],
1664 )?
1665 };
1666 // Backfill the same identity_id on db_agents user-clone rows.
1667 self.agents_dual_write_backfill_identity(new_identity_id)?;
1668 Ok(rows)
1669 }
1670
1671 /// Resolve the agent bindings tied to a block.
1672 ///
1673 /// Resolve through `block.meta.agentId` (or legacy
1674 /// `agent:id`) against `db_agents` for the user-clone case;
1675 /// fall back to the legacy `db_agent_instances` lookup by
1676 /// `block_id` for seeded-template launches and any other block
1677 /// the consolidated path can't satisfy.
1678 ///
1679 /// We deliberately do NOT consult `block.meta.agentInstanceId`:
1680 /// codex P1 on PR #1114 round 3 surfaced that pane reuse
1681 /// (`backToPicker` clears `agentId` but not `agentInstanceId`)
1682 /// and quick-launch (no instance-id stamp) leave the key stale.
1683 /// Trusting it would silently bleed the prior agent's identity
1684 /// across reopens — exactly the regression the legacy active-
1685 /// instance query avoided. The agentId-then-legacy path covers
1686 /// every launch shape without needing instance-id stamping:
1687 /// - User-clone: db_agents.id == def.id, hits is_template=0.
1688 /// - Template direct-launch: agentId points at a template
1689 /// (is_template=1, filtered out). Legacy fallback finds the
1690 /// active instance row keyed on block_id.
1691 /// - Pane reuse: stale `agentInstanceId` is ignored; current
1692 /// `agentId` wins.
1693 ///
1694 /// Replaces the legacy "find most recent active instance for
1695 /// this block" as the PRIMARY path for user-clones; the legacy
1696 /// query remains as fallback for templates + edge cases.
1697 /// Retires fully when Phase 3c drops the legacy table.
1698 /// Spec: docs/specs/SPEC_AGENT_ARCHITECTURE_2026_05_27.md §3b.
1699 ///
1700 /// `user_hidden` is NOT filtered — hiding a named agent
1701 /// ("forget") is a picker-visibility concept; the pane bound to
1702 /// that agent must keep resolving credentials. Codex P2 on PR
1703 /// #1114 round 2.
1704 ///
1705 /// Used by the identity resolver to pull `identity_id` /
1706 /// `memory_id` for environment injection on every command
1707 /// dispatch. Caller only reads `identity_id` from the returned
1708 /// `AgentInstance` — transient per-launch fields (status,
1709 /// session_id, started_at, ended_at, parent_instance_id) come
1710 /// back as type defaults. `block_id` echoes back the caller's
1711 /// argument.
1712 pub fn instance_get_active_for_block(
1713 &self,
1714 block_id: &str,
1715 ) -> Result<Option<AgentInstance>, StoreError> {
1716 let block: crate::backend::obj::Block = match self.get(block_id)? {
1717 Some(b) => b,
1718 None => return Ok(None),
1719 };
1720 let agent_id = block
1721 .meta
1722 .get("agentId")
1723 .and_then(|v| v.as_str())
1724 .or_else(|| block.meta.get("agent:id").and_then(|v| v.as_str()))
1725 .unwrap_or("")
1726 .to_string();
1727 let conn = self.conn.lock().unwrap();
1728 if !agent_id.is_empty() {
1729 let mut stmt = conn.prepare(
1730 "SELECT id, github_context, created_at,
1731 identity_id, memory_id, instance_name, working_directory
1732 FROM db_agents
1733 WHERE id = ?1
1734 AND is_template = 0",
1735 )?;
1736 let block_id_owned = block_id.to_string();
1737 let result = stmt.query_row(params![agent_id], |row| {
1738 Ok(AgentInstance {
1739 id: row.get(0)?,
1740 definition_id: row.get(0)?, // consolidated model — see 3b.3a
1741 parent_instance_id: String::new(),
1742 block_id: block_id_owned.clone(),
1743 session_id: String::new(),
1744 status: String::new(),
1745 github_context: row.get(1)?,
1746 started_at: row.get(2)?,
1747 ended_at: 0,
1748 created_at: row.get(2)?,
1749 identity_id: row.get(3)?,
1750 memory_id: row.get(4)?,
1751 instance_name: row.get(5)?,
1752 working_directory: row.get(6)?,
1753 display_hidden: false,
1754 })
1755 });
1756 match result {
1757 Ok(a) => return Ok(Some(a)),
1758 Err(rusqlite::Error::QueryReturnedNoRows) => {}
1759 Err(e) => return Err(e.into()),
1760 }
1761 }
1762 // Legacy-block fallback: seeded-template launches and any
1763 // block whose agentId references a row that doesn't exist
1764 // in the consolidated view. The active-instance query is
1765 // robust to pane reuse (it picks the most recent active
1766 // row keyed on block_id).
1767 let mut legacy_stmt = conn.prepare(
1768 "SELECT id, definition_id, parent_instance_id, block_id, session_id,
1769 status, github_context, started_at, ended_at, created_at,
1770 identity_id, memory_id, instance_name, working_directory,
1771 display_hidden
1772 FROM db_agent_instances
1773 WHERE block_id = ?1 AND status IN ('running', 'paused')
1774 ORDER BY created_at DESC
1775 LIMIT 1",
1776 )?;
1777 match legacy_stmt.query_row(params![block_id], map_instance_row) {
1778 Ok(a) => Ok(Some(a)),
1779 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1780 Err(e) => Err(e.into()),
1781 }
1782 }
1783
1784 // Dual-write helpers live in `super::dual_write`.
1785}
1786
1787fn map_instance_row(row: &rusqlite::Row) -> rusqlite::Result<AgentInstance> {
1788 let display_hidden_int: i64 = row.get(14)?;
1789 Ok(AgentInstance {
1790 id: row.get(0)?,
1791 definition_id: row.get(1)?,
1792 parent_instance_id: row.get(2)?,
1793 block_id: row.get(3)?,
1794 session_id: row.get(4)?,
1795 status: row.get(5)?,
1796 github_context: row.get(6)?,
1797 started_at: row.get(7)?,
1798 ended_at: row.get(8)?,
1799 created_at: row.get(9)?,
1800 identity_id: row.get(10)?,
1801 memory_id: row.get(11)?,
1802 instance_name: row.get(12)?,
1803 working_directory: row.get(13)?,
1804 display_hidden: display_hidden_int != 0,
1805 })
1806}
1807
1808/// Row mapper for `db_agents` rows projected back into the
1809/// `AgentDefinition` shape. The column order MUST match the SELECT in
1810/// `agent_def_list`. `parent_template_id` maps to `parent_id` because
1811/// the consolidated table renamed the field to clarify its semantics
1812/// (template lineage), but the wire shape kept the old name.
1813fn map_agent_definition_row(row: &rusqlite::Row) -> rusqlite::Result<AgentDefinition> {
1814 Ok(AgentDefinition {
1815 id: row.get(0)?,
1816 slug: row.get(1)?,
1817 name: row.get(2)?,
1818 icon: row.get(3)?,
1819 provider: row.get(4)?,
1820 description: row.get(5)?,
1821 working_directory: row.get(6)?,
1822 shell: row.get(7)?,
1823 provider_flags: row.get(8)?,
1824 auto_start: row.get(9)?,
1825 restart_on_crash: row.get(10)?,
1826 idle_timeout_minutes: row.get(11)?,
1827 created_at: row.get(12)?,
1828 agent_type: row.get(13)?,
1829 environment: row.get(14)?,
1830 agent_bus_id: row.get(15)?,
1831 is_seeded: row.get(16)?,
1832 accounts: row.get(17)?,
1833 parent_id: row.get(18)?,
1834 branch_label: row.get(19)?,
1835 updated_at: row.get(20)?,
1836 user_hidden: row.get(21)?,
1837 container_image: row.get(22)?,
1838 container_volumes: row.get(23)?,
1839 container_name: row.get(24)?,
1840 })
1841}